Skip to content

fix: true streaming reads in KurrentDB store and paged read-to-end API - #568

Merged
alexeyzimarev merged 12 commits into
devfrom
fix/streaming-reads-567
Aug 19, 2026
Merged

fix: true streaming reads in KurrentDB store and paged read-to-end API#568
alexeyzimarev merged 12 commits into
devfrom
fix/streaming-reads-567

Conversation

@alexeyzimarev

@alexeyzimarev alexeyzimarev commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes #567

Summary

  • True streaming reads in KurrentDBEventStore: ReadEvents and ReadEventsBackwards now yield each event as it arrives from the server instead of materializing the whole requested range into two stream-sized arrays before the first yield. Exception mapping (StreamNotFound, ReadFromStreamException) and logging are preserved by wrapping each advance of the source enumerator; OperationCanceledException now propagates instead of being wrapped.
  • First-class read-to-end: new IEventReader.ReadStreamToEnd extension returns IAsyncEnumerable<StreamEvent>, reading in pages (default 500, tunable) and advancing from the last yielded event's revision — bounded memory on every provider, so count: int.MaxValue stops being the idiom. ReadStream now delegates to it, which also fixes its page advancement for truncated streams.
  • Documented memory semantics on IEventReader.ReadEvents/ReadEventsBackwards: implementations either stream or buffer up to count, and whole-stream reads should use ReadStreamToEnd.

New shared contract tests exposed two pre-existing provider bugs, fixed here:

  • Sqlite never threw StreamNotFound when reading a missing stream (a plain SELECT can't tell a missing stream from a read past the end). SqlEventStoreBase now checks StreamExists when a read returns no rows.
  • Postgres and SqlServer crashed reading backwards from StreamReadPosition.End: long.MaxValue was marshalled into an INT parameter (arithmetic overflow). The parameter is now clamped to the 32-bit position range, which is lossless since both schemas store positions as INT and both procedures already trim the position to the stream head.

Test plan

  • New StreamingReadTests (KurrentDB) prove streaming with a counting serializer: exactly 1 event deserialized at first yield, previously the full range
  • New shared StoreReadTests cases (inherited by KurrentDB, Postgres, SqlServer, Sqlite): read-to-end across page boundaries, exact page multiples, from a position, missing-stream throw/empty behavior, and missing-stream contract for plain reads
  • Full affected suites green on net10.0: KurrentDB 68/68, Postgres 44/44, SqlServer 48/48, Sqlite 34/34, core Eventuous.Tests 26/26

🤖 Generated with Claude Code

Review-driven hardening (rounds 2–5 of the independent code review)

  • KurrentDBEventStore reads now deliver the requested count even when non-deserializable $-typed events are skipped (follow-up server reads), so a short read reliably means the stream end — the invariant ReadStreamToEnd paging relies on, now documented on IEventReader
  • ReadStreamToEnd rejects non-positive page sizes (previously spun forever on relational stores)
  • TieredEventReader: past-end reads of existing streams return empty instead of throwing; archive gap-fill and combined output are bounded by the requested count; backwards reads no longer crash when the hot tier bottoms out at revision 0
  • RedisStore: reads are now inclusive (XRANGE) matching the position contract; the append function assigns explicit round-trippable entry IDs (<ms>-0), fixing silent event loss at page boundaries for same-millisecond bursts

Redis legacy data caveat

Streams written by earlier versions may contain auto-generated entry IDs with sequence numbers ≥ 10, which the ms*10+seq position encoding cannot represent. Reading such an entry, or reading from a position whose gap hides one, now throws NotSupportedException naming the entry. Positions minted by pre-fix versions from such entries are inherently ambiguous (the encoding maps e.g. both legacy 12345-20 and valid 12347-0 to 123470) and cannot be detected without breaking valid reads — resume positions for such streams should be re-derived by reading the stream from the start, which fails loudly and identifies the offending entry. A storage-format migration for legacy burst streams is a candidate follow-up issue.

KurrentDB ReadEvents/ReadEventsBackwards materialized the entire requested
range (raw ResolvedEvent[] plus deserialized StreamEvent[]) before the first
yield, so IAsyncEnumerable consumers got O(stream) memory instead of
streaming. Rewrite both as true streaming iterators that map exceptions per
enumerator advance and hold at most one deserialized event at a time.

Add IEventReader.ReadStreamToEnd extension that reads a stream to the end in
pages, so count: int.MaxValue stops being the read-to-end idiom, and make
ReadStream delegate to it, fixing page advancement for truncated streams.
Document the memory semantics on IEventReader.

New contract tests exposed two pre-existing provider bugs, also fixed:
- Sqlite reads never threw StreamNotFound for a missing stream; empty read
  results are now verified with StreamExists in SqlEventStoreBase
- Postgres and SqlServer overflowed reading backwards from
  StreamReadPosition.End (long.MaxValue into an INT parameter); the client
  parameter is now clamped to the 32-bit position range

Closes #567

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

True streaming reads for KurrentDB and paged ReadStreamToEnd API

🐞 Bug fix ✨ Enhancement 🧪 Tests 📝 Documentation 🕐 40+ Minutes

Grey Divider

AI Description

• Make KurrentDB reads yield events as received, avoiding full-range buffering.
• Add paged ReadStreamToEnd to read whole streams with bounded memory.
• Strengthen cross-provider missing-stream/backwards-read contracts and fix relational edge cases.
Diagram

graph TD
  A["Async consumer"] --> B["StoreFunctions"] --> C["IEventReader"]
  C --> D["KurrentDBEventStore"] --> E["KurrentDB client"]
  C --> F["SqlEventStoreBase"] --> G["Postgres/SqlServer stores"]
  H["Contract & streaming tests"] --> C
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Add ReadStreamToEnd to IEventReader interface
  • ➕ Makes read-to-end capability explicit and discoverable in all implementations
  • ➕ Allows providers to optimize read-to-end behavior beyond generic paging
  • ➖ Breaking change for all implementers of IEventReader
  • ➖ Harder to ship as a patch-level fix compared to an extension method
2. Provider-native read-to-end APIs (no generic paging loop)
  • ➕ Potentially fewer round-trips and better server-side continuation support
  • ➕ Can better handle truncation/head-movement semantics per store
  • ➖ More duplicated logic across providers
  • ➖ Harder to enforce consistent missing-stream semantics and paging behavior
3. Keep ReadStream(count: int.MaxValue) as the idiom, document only
  • ➕ No new API surface
  • ➕ Lowest code change footprint
  • ➖ Still encourages unbounded buffering in non-streaming implementations
  • ➖ Hard to reason about memory usage and paging correctness across providers

Recommendation: The chosen approach (ReadStreamToEnd as an extension over ReadEvents with explicit pageSize) is the best trade-off for a patch release: it standardizes bounded-memory whole-stream reads without breaking IEventReader implementers, and it enables shared contract tests to enforce consistent semantics. If a future major version is planned, consider promoting ReadStreamToEnd into IEventReader to make the capability first-class and allow provider-specific optimizations.

Files changed (8) +287 / -57

Enhancement (1) +57 / -15
StoreFunctions.csAdd paged ReadStreamToEnd and delegate ReadStream to it +57/-15

Add paged ReadStreamToEnd and delegate ReadStream to it

• Introduces ReadStreamToEnd as an IAsyncEnumerable that reads pages via ReadEvents, yields events as they arrive, and advances by last yielded revision. Refactors ReadStream (array materialization) to enumerate ReadStreamToEnd, fixing page advancement for truncated streams and avoiding the int.MaxValue idiom.

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs

Bug fix (4) +57 / -42
KurrentDBEventStore.csRewrite KurrentDB reads as true streaming async iterators +47/-40

Rewrite KurrentDB reads as true streaming async iterators

• Replaces array-materializing reads with an iterator that advances the underlying enumerator and yields one deserialized StreamEvent at a time. Preserves StreamNotFound mapping and logging while ensuring OperationCanceledException propagates without wrapping.

src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs

PostgresStore.csClamp backwards read start position to 32-bit range +2/-1

Clamp backwards read start position to 32-bit range

• Fixes overflow when reading backwards from StreamReadPosition.End by clamping the start position to int.MaxValue before binding to an integer parameter. Keeps semantics lossless since schema/procs store and trim positions as INT.

src/Postgres/src/Eventuous.Postgresql/PostgresStore.cs

SqlEventStoreBase.csThrow StreamNotFound when empty reads come from missing streams +6/-0

Throw StreamNotFound when empty reads come from missing streams

• Adds a StreamExists check when forward/backward reads return zero rows, distinguishing missing streams from reads past the end. Ensures relational providers conform to the missing-stream contract tests.

src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs

SqlServerStore.csClamp backwards read start position to 32-bit range +2/-1

Clamp backwards read start position to 32-bit range

• Fixes arithmetic overflow when binding StreamReadPosition.End (long.MaxValue) to an INT stored procedure parameter by clamping to int.MaxValue. Matches existing schema/procedure behavior that trims to the stream head.

src/SqlServer/src/Eventuous.SqlServer/SqlServerStore.cs

Tests (2) +167 / -0
Read.csExtend store contract tests for missing-stream and read-to-end behavior +96/-0

Extend store contract tests for missing-stream and read-to-end behavior

• Adds shared tests asserting StreamNotFound behavior for missing streams (forward/backward) and validates ReadStreamToEnd paging across boundaries, exact page multiples, and start positions. Also verifies optional failIfNotFound behavior for read-to-end.

src/Core/test/Eventuous.Tests.Persistence.Base/Store/Read.cs

StreamingReadTests.csAdd tests proving KurrentDB reads don’t buffer entire ranges +71/-0

Add tests proving KurrentDB reads don’t buffer entire ranges

• Introduces StreamingReadTests using a counting serializer to assert exactly one event is deserialized at first yield for forward and backward reads. Ensures total deserialization count matches event count, validating true streaming behavior.

src/KurrentDB/test/Eventuous.Tests.KurrentDB/Store/StreamingReadTests.cs

Documentation (1) +6 / -0
IEventReader.csDocument memory semantics of streaming vs buffering reads +6/-0

Document memory semantics of streaming vs buffering reads

• Expands XML docs for ReadEvents/ReadEventsBackwards to clarify that implementations may stream or buffer up to count. Directs whole-stream reads to the new ReadStreamToEnd helper instead of using int.MaxValue.

src/Core/src/Eventuous.Persistence/EventStore/IEventReader.cs

Comment thread src/KurrentDB/src/Eventuous.KurrentDB/KurrentDBEventStore.cs Fixed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 12c0020b87

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

public async IAsyncEnumerable<StreamEvent> ReadStreamToEnd(
StreamName streamName,
StreamReadPosition start,
int pageSize = 500,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject non-positive page sizes

When pageSize is zero, readers such as SqlEventStoreBase return an empty page, but yielded < pageSize is false, so the outer loop repeats indefinitely and continuously queries the store until cancellation. Negative values can behave similarly for readers that treat non-positive counts as empty. Validate that this public argument is greater than zero before entering the paging loop.

Useful? React with 👍 / 👎.

yield return evt;
}

if (yielded < pageSize) yield break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Track source-page exhaustion instead of yielded events

When a KurrentDB page contains an event that EnumerateStream suppresses, such as an unresolved $> link event whose deserialization fails, the enumerable yields fewer than pageSize items even though the underlying raw page was full and later pages exist. Treating the number of yielded user events as proof that the source reached its end therefore makes ReadStreamToEnd silently omit the remaining events; page exhaustion must be tracked independently of filtered events.

Useful? React with 👍 / 👎.

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Infinite loop on pageSize ✓ Resolved 🐞 Bug ☼ Reliability
Description
StoreFunctions.ReadStreamToEnd can loop forever when pageSize <= 0 because it only exits when
yielded < pageSize, which is false for yielded == 0 and pageSize <= 0, so it keeps advancing
position and issuing empty reads.
Code

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[R200-203]

+                if (yielded < pageSize) yield break;
+
+                position = new(lastRevision + 1);
+            }
Evidence
ReadStreamToEnd terminates only when yielded < pageSize; for pageSize <= 0, a page that yields
0 events will not satisfy this exit condition, and the code will compute a new position and repeat.
Some store implementations (e.g., SQL base) explicitly yield-break for count <= 0, making
yielded remain 0 forever under invalid pageSize values.

src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[167-204]
src/Relational/src/Eventuous.Sql.Base/SqlEventStoreBase.cs[100-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ReadStreamToEnd` does not validate `pageSize`. When `pageSize <= 0`, the paging loop can become non-terminating (especially for implementations that yield no events for `count <= 0`), repeatedly performing empty reads.
### Issue Context
This method is a new public read-to-end API intended for safe, bounded-memory paging. Invalid `pageSize` values should fail fast (or be normalized) to prevent hangs.
### Fix Focus Areas
- src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[167-204]
### Suggested fix
- Add an argument guard at the start of `ReadStreamToEnd`, e.g.:
- `if (pageSize <= 0) throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be > 0");`
- (Optional) Add a regression test ensuring `pageSize: 0` (and negative) throws `ArgumentOutOfRangeException`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can show, collapse, or hide each part of a finding: code, evidence, and all

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs Outdated
@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

Test Results

 46 files  + 24   46 suites  +24   12m 30s ⏱️ -1s
427 tests + 58  427 ✅ + 58  0 💤 ±0  0 ❌ ±0 
770 runs  +390  770 ✅ +390  0 💤 ±0  0 ❌ ±0 

Results for commit 9b7fe62. ± Comparison against base commit 3cb68c2.

This pull request removes 5 and adds 63 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 14:31:31 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/03/2026 14:31:31)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(d6ea070b-8d6b-46d6-9e04-bb9b4acedc59)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-08-03T14:31:52.5505416+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-08-03T14:31:52.5505416+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/19/2026 14:49:50 +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(08/19/2026 14:49:50)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(9759415d-1aa0-47bc-bea2-85b78eb50158)
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEnd
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEndFromPosition
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReadStreamToEndWithExactPageMultiple
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldRejectInvalidPageSizeReadingToEnd
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldReturnNothingWhenReadingMissingStreamToEnd
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldThrowWhenReadingMissingStream
Eventuous.Tests.KurrentDB.Store.Read ‑ ShouldThrowWhenReadingMissingStreamBackwards
…

♻️ This comment has been updated with latest results.

Address review findings on the streaming reads change:

- KurrentDBEventStore reads now deliver the requested count even when
  non-deserializable system events are skipped, issuing follow-up reads from
  the last received position. A short read now reliably means the stream end,
  which ReadStreamToEnd's paging termination depends on.
- ReadStreamToEnd rejects non-positive page sizes instead of spinning forever
  on readers that complete immediately for count <= 0.
- TieredEventReader no longer throws StreamNotFound when reading past the end
  of an existing stream; it throws only when both tiers report the stream
  missing.
- RedisStore distinguishes a missing stream from a read past the stream end
  by checking key existence when a read returns nothing.
- IEventReader docs now state the short-read and past-end contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +283 to +291
} catch (Exception ex) {
var (message, args) = getError();
// ReSharper disable once TemplateIsNotCompileTimeConstantProblem
#pragma warning disable CA2254
_logger.LogWarning(ex, message, args);
#pragma warning restore CA2254

return ToStreamEvents(resolvedEvents);
},
stream,
true,
() => new("Unable to read {Count} events backwards from {Stream}", count, stream),
(s, ex) => new ReadFromStreamException(s, ex)
);
throw new ReadFromStreamException(stream, ex);
}
alexeyzimarev and others added 3 commits August 19, 2026 15:18
Address the second review round:

- TieredEventReader bounds the archive gap request and the combined result
  to the requested count, so a read across a real archive/hot boundary no
  longer yields more events than asked for. Reading backwards past a hot
  store that bottoms out at revision 0 no longer crashes constructing a
  negative read position.
- RedisStore reads use an inclusive range read (XRANGE) instead of the
  exclusive XREAD, matching the IEventReader position contract and the paged
  read extensions that advance from the last revision + 1 — pages no longer
  silently skip the event at the page boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the third review round:

- The append_events Redis function now assigns explicit entry IDs
  (millisecond-0, bumping the millisecond past the last entry when needed)
  instead of auto-generated ones, so every position the store writes
  round-trips through the millisecond*10+sequence encoding and paged reads
  no longer silently truncate same-millisecond bursts.
- Reading a legacy entry whose auto-generated ID carries a sequence number
  above 9 now throws NotSupportedException with a clear message instead of
  silently garbling the position.
- Relax IEventReader/ReadStreamToEnd memory docs to promise memory
  proportional to count/page size rather than capped at one page, matching
  the tiered reader which briefly holds up to two bounded pages.
- Stabilize the Redis test fixture: abortConnect=false stops the first
  connection attempt from aborting when it races the freshly started
  container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…dary

A legacy auto-generated entry ID with sequence 10+ sorts below the decoded
form of the position that follows sequence 9, so a paged read could skip it
before the read-side guard ever materialized it. Reads now probe the gap
between the requested position and its decoded ID and throw
NotSupportedException when unreachable legacy entries exist there.

Also reverts the test fixture connection hardening, moved to a separate PR
to keep this one focused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev and others added 3 commits August 19, 2026 15:53
Document that the read-side gap probe is complete for every position this
store version can produce, and why positions minted by pre-fix versions from
unrepresentable entries are inherently ambiguous (the encoding maps both
legacy 12345-20 and valid 12347-0 to 123470). Add a test pinning that any
read from the start of a legacy burst stream rejects the first
unrepresentable entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…y IDs

A position minted by a pre-fix reader from a legacy entry with a
multi-carry sequence number decodes past other legacy entries, which a
resumed read then silently skipped. Since every entry that can hide from a
position has a sequence number above 9, a stream is safe for resumed reads
exactly when it holds no such entry. Reads from a non-zero position now
verify that server-side (check_stream_clean function, clean verdict cached
in a hash; entries written by the current store always carry sequence 0)
and conservatively reject dirty streams with NotSupportedException naming
the offending entry, replacing the single-carry gap probe. The caveat and
the read-from-start migration guidance are documented on the public
ReadEvents API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Address the seventh review round:

- Replace the server-side clean-stream function and its permanent name-keyed
  Redis marker with client-side validation in the store: the stream is
  scanned in bounded XRANGE pages that observe the caller's cancellation
  token, so the Redis server is never blocked for the length of the stream.
- The verdict is cached per store instance, anchored on the first entry ID:
  Redis only accepts appends with increasing entry IDs, so a validated
  prefix can't gain entries, later reads only scan the delta above the last
  validated ID, a recreated stream (different first entry) triggers a full
  rescan, and a missing stream records no verdict — deleting, recreating,
  restoring, or importing legacy entries can no longer inherit a stale
  clean verdict.
- Legacy-entry seeding in tests shares one connection handle instead of
  opening one per appended entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Comment on lines +122 to +129
foreach (var entry in batch) {
if (EntrySequence(entry.Id) > 9) {
throw new NotSupportedException(
$"Stream {stream} can't be read from a non-zero position: it contains entry ID {entry.Id}, which the position encoding can't represent (only ID sequence numbers 0-9 are supported). " +
"Entries with higher sequence numbers were written with auto-generated IDs by an older version of the store. Read the stream from the start and migrate it."
);
}
}
alexeyzimarev and others added 4 commits August 19, 2026 16:27
Address the eighth review round: no cache anchored on observable stream
state is sound, because Redis exposes no immutable per-key generation
identity — a stream restored with the same first entry defeated the
first-entry anchor, and the cache grew unboundedly per stream name.
Resumed reads now validate the prefix below the decoded position on every
call, in bounded cancellable pages, scanning only entries the read itself
won't materialize. Stateless validation can't go stale, holds no memory,
and closes the head-read/scan TOCTOU; the per-read cost is documented on
the public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Redis stream ID components are unsigned 64-bit values; parsing the sequence
with long.Parse raised OverflowException for sequences beyond long range
instead of the documented NotSupportedException. Both the revision
conversion and the validation scan now parse as ulong and range-check, and
the millisecond part is range-checked before the signed conversion.

Also document the operational requirement that pre-fix writers are quiesced
before resumed reads are used: an old writer racing the gap between prefix
validation and the data read can append an unrepresentable entry below the
requested position, which only the next resumed read can reject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
At milliseconds == long.MaxValue / 10 only sequences up to
long.MaxValue % 10 fit the signed position; sequence 8 and 9 wrapped to a
negative revision instead of throwing the documented NotSupportedException.
Also widen the documented quiescence requirement to every writer that
doesn't use this store version's explicit entry ID scheme, including
external XADD with auto-generated IDs, not only pre-0.16 store versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An event at revision long.MaxValue that fills an exact page made
ReadStreamToEnd compute lastRevision + 1, wrapping negative and throwing
from the StreamReadPosition constructor after yielding the event. The
maximum revision is the end of the representable position space, so the
paged read now completes there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev added a commit to Eventuous/eventuous-docs that referenced this pull request Aug 19, 2026
Document the enforced read contract (exact count unless stream end,
past-end reads return empty), the KurrentDB system-event compensation,
the tiered reader fixes, the pageSize validation, and the Redis inclusive
position semantics with the legacy-stream rejection caveat. Matches
Eventuous/eventuous#568 as hardened by its review.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev added a commit to Eventuous/eventuous-plugin that referenced this pull request Aug 19, 2026
State the enforced read contract (exact count unless stream end, past-end
reads return empty, missing stream throws), the KurrentDB system-event
compensation, and the pageSize validation. Matches Eventuous/eventuous#568.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@alexeyzimarev
alexeyzimarev merged commit a102683 into dev Aug 19, 2026
16 checks passed
@alexeyzimarev
alexeyzimarev deleted the fix/streaming-reads-567 branch August 19, 2026 15:38
alexeyzimarev added a commit to Eventuous/eventuous-plugin that referenced this pull request Aug 19, 2026
* Add stream reading guidance: ReadStreamToEnd, memory semantics

Document the IEventReader read semantics (KurrentDB streams events as they
arrive, relational stores buffer up to count) and steer agents to
ReadStreamToEnd for whole-stream reads instead of ReadEvents with
int.MaxValue. Matches Eventuous/eventuous#568.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Add reader contract details from the review-hardened streaming changes

State the enforced read contract (exact count unless stream end, past-end
reads return empty, missing stream throws), the KurrentDB system-event
compensation, and the pageSize validation. Matches Eventuous/eventuous#568.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev added a commit to quezlatch/eventuous that referenced this pull request Aug 19, 2026
Eventuous#568)

* fix(persistence): make reads stream and add paged read-to-end

KurrentDB ReadEvents/ReadEventsBackwards materialized the entire requested
range (raw ResolvedEvent[] plus deserialized StreamEvent[]) before the first
yield, so IAsyncEnumerable consumers got O(stream) memory instead of
streaming. Rewrite both as true streaming iterators that map exceptions per
enumerator advance and hold at most one deserialized event at a time.

Add IEventReader.ReadStreamToEnd extension that reads a stream to the end in
pages, so count: int.MaxValue stops being the read-to-end idiom, and make
ReadStream delegate to it, fixing page advancement for truncated streams.
Document the memory semantics on IEventReader.

New contract tests exposed two pre-existing provider bugs, also fixed:
- Sqlite reads never threw StreamNotFound for a missing stream; empty read
  results are now verified with StreamExists in SqlEventStoreBase
- Postgres and SqlServer overflowed reading backwards from
  StreamReadPosition.End (long.MaxValue into an INT parameter); the client
  parameter is now clamped to the 32-bit position range

Closes Eventuous#567

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): harden paged reads per review findings

Address review findings on the streaming reads change:

- KurrentDBEventStore reads now deliver the requested count even when
  non-deserializable system events are skipped, issuing follow-up reads from
  the last received position. A short read now reliably means the stream end,
  which ReadStreamToEnd's paging termination depends on.
- ReadStreamToEnd rejects non-positive page sizes instead of spinning forever
  on readers that complete immediately for count <= 0.
- TieredEventReader no longer throws StreamNotFound when reading past the end
  of an existing stream; it throws only when both tiers report the stream
  missing.
- RedisStore distinguishes a missing stream from a read past the stream end
  by checking key existence when a read returns nothing.
- IEventReader docs now state the short-read and past-end contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): bound tiered reads and make Redis reads inclusive

Address the second review round:

- TieredEventReader bounds the archive gap request and the combined result
  to the requested count, so a read across a real archive/hot boundary no
  longer yields more events than asked for. Reading backwards past a hot
  store that bottoms out at revision 0 no longer crashes constructing a
  negative read position.
- RedisStore reads use an inclusive range read (XRANGE) instead of the
  exclusive XREAD, matching the IEventReader position contract and the paged
  read extensions that advance from the last revision + 1 — pages no longer
  silently skip the event at the page boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): make stream positions round-trip for store-written entries

Address the third review round:

- The append_events Redis function now assigns explicit entry IDs
  (millisecond-0, bumping the millisecond past the last entry when needed)
  instead of auto-generated ones, so every position the store writes
  round-trips through the millisecond*10+sequence encoding and paged reads
  no longer silently truncate same-millisecond bursts.
- Reading a legacy entry whose auto-generated ID carries a sequence number
  above 9 now throws NotSupportedException with a clear message instead of
  silently garbling the position.
- Relax IEventReader/ReadStreamToEnd memory docs to promise memory
  proportional to count/page size rather than capped at one page, matching
  the tiered reader which briefly holds up to two bounded pages.
- Stabilize the Redis test fixture: abortConnect=false stops the first
  connection attempt from aborting when it races the freshly started
  container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): fail loudly on legacy entry IDs hidden behind a page boundary

A legacy auto-generated entry ID with sequence 10+ sorts below the decoded
form of the position that follows sequence 9, so a paged read could skip it
before the read-side guard ever materialized it. Reads now probe the gap
between the requested position and its decoded ID and throw
NotSupportedException when unreachable legacy entries exist there.

Also reverts the test fixture connection hardening, moved to a separate PR
to keep this one focused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(redis): state the legacy position detection boundary

Document that the read-side gap probe is complete for every position this
store version can produce, and why positions minted by pre-fix versions from
unrepresentable entries are inherently ambiguous (the encoding maps both
legacy 12345-20 and valid 12347-0 to 123470). Add a test pinning that any
read from the start of a legacy burst stream rejects the first
unrepresentable entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): reject resumed reads on streams with unrepresentable entry IDs

A position minted by a pre-fix reader from a legacy entry with a
multi-carry sequence number decodes past other legacy entries, which a
resumed read then silently skipped. Since every entry that can hide from a
position has a sequence number above 9, a stream is safe for resumed reads
exactly when it holds no such entry. Reads from a non-zero position now
verify that server-side (check_stream_clean function, clean verdict cached
in a hash; entries written by the current store always carry sequence 0)
and conservatively reject dirty streams with NotSupportedException naming
the offending entry, replacing the single-carry gap probe. The caveat and
the read-from-start migration guidance are documented on the public
ReadEvents API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): make position validation incremental and its cache sound

Address the seventh review round:

- Replace the server-side clean-stream function and its permanent name-keyed
  Redis marker with client-side validation in the store: the stream is
  scanned in bounded XRANGE pages that observe the caller's cancellation
  token, so the Redis server is never blocked for the length of the stream.
- The verdict is cached per store instance, anchored on the first entry ID:
  Redis only accepts appends with increasing entry IDs, so a validated
  prefix can't gain entries, later reads only scan the delta above the last
  validated ID, a recreated stream (different first entry) triggers a full
  rescan, and a missing stream records no verdict — deleting, recreating,
  restoring, or importing legacy entries can no longer inherit a stale
  clean verdict.
- Legacy-entry seeding in tests shares one connection handle instead of
  opening one per appended entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): drop the position validation cache, validate per read

Address the eighth review round: no cache anchored on observable stream
state is sound, because Redis exposes no immutable per-key generation
identity — a stream restored with the same first entry defeated the
first-entry anchor, and the cache grew unboundedly per stream name.
Resumed reads now validate the prefix below the decoded position on every
call, in bounded cancellable pages, scanning only entries the read itself
won't materialize. Stateless validation can't go stale, holds no memory,
and closes the head-read/scan TOCTOU; the per-read cost is documented on
the public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): honor the exception contract for unsigned ID sequences

Redis stream ID components are unsigned 64-bit values; parsing the sequence
with long.Parse raised OverflowException for sequences beyond long range
instead of the documented NotSupportedException. Both the revision
conversion and the validation scan now parse as ulong and range-check, and
the millisecond part is range-checked before the signed conversion.

Also document the operational requirement that pre-fix writers are quiesced
before resumed reads are used: an old writer racing the gap between prefix
validation and the data read can append an unrepresentable entry below the
requested position, which only the next resumed read can reject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): reject positions overflowing at the encoding boundary

At milliseconds == long.MaxValue / 10 only sequences up to
long.MaxValue % 10 fit the signed position; sequence 8 and 9 wrapped to a
negative revision instead of throwing the documented NotSupportedException.
Also widen the documented quiescence requirement to every writer that
doesn't use this store version's explicit entry ID scheme, including
external XADD with auto-generated IDs, not only pre-0.16 store versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): stop paged reads advancing past the maximum revision

An event at revision long.MaxValue that fills an exact page made
ReadStreamToEnd compute lastRevision + 1, wrapping negative and throwing
from the StreamReadPosition constructor after yielding the event. The
maximum revision is the end of the representable position space, so the
paged read now completes there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
alexeyzimarev added a commit that referenced this pull request Aug 19, 2026
* initial aspire

* test: add Eventuous.Tests.Azure.Storage.Blobs project with tests for StorageBlobsProjector

- Create new test project following Eventuous.Tests.Azure.ServiceBus structure
- Add Testcontainers.Azurite package to Directory.Packages.props
- Add IntegrationFixture with Azurite and KurrentDB containers
- Test all On method variants (sync/async, state/context) for new and existing blobs
- Test concurrent modification scenario (412 Precondition Failed)
- Test no handler scenario (returns Ignored)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* refactor: extract duplication from StorageBlobsProjectorTests and surface intent

- Add helper methods: SetupContainer, SetupExistingBlob, GetBlobState, AssertSuccess, AssertIgnored
- Rename projector classes to surface handler patterns (SyncStateProjector, etc.)
- Group tests by handler variant with clear section comments
- Test names now follow [Variant]_[Scenario]_[ExpectedBehavior] pattern
- Reduce LOC from ~450 to ~330 (-27%)
- Remove fixture parameter from CreateContext (unused)

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* feat: add constructor overload to StorageBlobsProjector that takes BlobServiceClient and container name

- Add StorageBlobsProjector(BlobServiceClient, string containerName) constructor
- Update test helper methods to work with container names instead of BlobContainerClient
- Add GetContainer() helper to get BlobContainerClient from fixture
- Update all test projector classes with new constructor overload
- Update all tests to use fixture.BlobServiceClient with container names

Generated by Mistral Vibe.
Co-Authored-By: Mistral Vibe <vibe@mistral.ai>

* fix tests

* make context typed in On methods

* update azure sample to use blob storage

* update aspire sql databases

* add race condition check

* refactor

* add projector options

* enhance blob name resolution in projection

* adjust blob name generation

* refactor

* add xml docs

* documentation

* add scalar as swagger/openapi client

* refactor

* retries on race conditions

* tidy

* oops

* review feedback

* add .NoContext()

* correct aspire http endpoints

* remove azure sample

* add idempotency functionality

* refactor tests

* update readme

* tidy

* tidy

* update readme

* make options non-generic by removing serialisation overrides

* do not use IOptions wrapper

* rename files

* fix readme

* refine by global position idempotency

* update readme for ByMessageId

* fix: true streaming reads in KurrentDB store and paged read-to-end API (#568)

* fix(persistence): make reads stream and add paged read-to-end

KurrentDB ReadEvents/ReadEventsBackwards materialized the entire requested
range (raw ResolvedEvent[] plus deserialized StreamEvent[]) before the first
yield, so IAsyncEnumerable consumers got O(stream) memory instead of
streaming. Rewrite both as true streaming iterators that map exceptions per
enumerator advance and hold at most one deserialized event at a time.

Add IEventReader.ReadStreamToEnd extension that reads a stream to the end in
pages, so count: int.MaxValue stops being the read-to-end idiom, and make
ReadStream delegate to it, fixing page advancement for truncated streams.
Document the memory semantics on IEventReader.

New contract tests exposed two pre-existing provider bugs, also fixed:
- Sqlite reads never threw StreamNotFound for a missing stream; empty read
  results are now verified with StreamExists in SqlEventStoreBase
- Postgres and SqlServer overflowed reading backwards from
  StreamReadPosition.End (long.MaxValue into an INT parameter); the client
  parameter is now clamped to the 32-bit position range

Closes #567

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): harden paged reads per review findings

Address review findings on the streaming reads change:

- KurrentDBEventStore reads now deliver the requested count even when
  non-deserializable system events are skipped, issuing follow-up reads from
  the last received position. A short read now reliably means the stream end,
  which ReadStreamToEnd's paging termination depends on.
- ReadStreamToEnd rejects non-positive page sizes instead of spinning forever
  on readers that complete immediately for count <= 0.
- TieredEventReader no longer throws StreamNotFound when reading past the end
  of an existing stream; it throws only when both tiers report the stream
  missing.
- RedisStore distinguishes a missing stream from a read past the stream end
  by checking key existence when a read returns nothing.
- IEventReader docs now state the short-read and past-end contract.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): bound tiered reads and make Redis reads inclusive

Address the second review round:

- TieredEventReader bounds the archive gap request and the combined result
  to the requested count, so a read across a real archive/hot boundary no
  longer yields more events than asked for. Reading backwards past a hot
  store that bottoms out at revision 0 no longer crashes constructing a
  negative read position.
- RedisStore reads use an inclusive range read (XRANGE) instead of the
  exclusive XREAD, matching the IEventReader position contract and the paged
  read extensions that advance from the last revision + 1 — pages no longer
  silently skip the event at the page boundary.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): make stream positions round-trip for store-written entries

Address the third review round:

- The append_events Redis function now assigns explicit entry IDs
  (millisecond-0, bumping the millisecond past the last entry when needed)
  instead of auto-generated ones, so every position the store writes
  round-trips through the millisecond*10+sequence encoding and paged reads
  no longer silently truncate same-millisecond bursts.
- Reading a legacy entry whose auto-generated ID carries a sequence number
  above 9 now throws NotSupportedException with a clear message instead of
  silently garbling the position.
- Relax IEventReader/ReadStreamToEnd memory docs to promise memory
  proportional to count/page size rather than capped at one page, matching
  the tiered reader which briefly holds up to two bounded pages.
- Stabilize the Redis test fixture: abortConnect=false stops the first
  connection attempt from aborting when it races the freshly started
  container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): fail loudly on legacy entry IDs hidden behind a page boundary

A legacy auto-generated entry ID with sequence 10+ sorts below the decoded
form of the position that follows sequence 9, so a paged read could skip it
before the read-side guard ever materialized it. Reads now probe the gap
between the requested position and its decoded ID and throw
NotSupportedException when unreachable legacy entries exist there.

Also reverts the test fixture connection hardening, moved to a separate PR
to keep this one focused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(redis): state the legacy position detection boundary

Document that the read-side gap probe is complete for every position this
store version can produce, and why positions minted by pre-fix versions from
unrepresentable entries are inherently ambiguous (the encoding maps both
legacy 12345-20 and valid 12347-0 to 123470). Add a test pinning that any
read from the start of a legacy burst stream rejects the first
unrepresentable entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): reject resumed reads on streams with unrepresentable entry IDs

A position minted by a pre-fix reader from a legacy entry with a
multi-carry sequence number decodes past other legacy entries, which a
resumed read then silently skipped. Since every entry that can hide from a
position has a sequence number above 9, a stream is safe for resumed reads
exactly when it holds no such entry. Reads from a non-zero position now
verify that server-side (check_stream_clean function, clean verdict cached
in a hash; entries written by the current store always carry sequence 0)
and conservatively reject dirty streams with NotSupportedException naming
the offending entry, replacing the single-carry gap probe. The caveat and
the read-from-start migration guidance are documented on the public
ReadEvents API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): make position validation incremental and its cache sound

Address the seventh review round:

- Replace the server-side clean-stream function and its permanent name-keyed
  Redis marker with client-side validation in the store: the stream is
  scanned in bounded XRANGE pages that observe the caller's cancellation
  token, so the Redis server is never blocked for the length of the stream.
- The verdict is cached per store instance, anchored on the first entry ID:
  Redis only accepts appends with increasing entry IDs, so a validated
  prefix can't gain entries, later reads only scan the delta above the last
  validated ID, a recreated stream (different first entry) triggers a full
  rescan, and a missing stream records no verdict — deleting, recreating,
  restoring, or importing legacy entries can no longer inherit a stale
  clean verdict.
- Legacy-entry seeding in tests shares one connection handle instead of
  opening one per appended entry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): drop the position validation cache, validate per read

Address the eighth review round: no cache anchored on observable stream
state is sound, because Redis exposes no immutable per-key generation
identity — a stream restored with the same first entry defeated the
first-entry anchor, and the cache grew unboundedly per stream name.
Resumed reads now validate the prefix below the decoded position on every
call, in bounded cancellable pages, scanning only entries the read itself
won't materialize. Stateless validation can't go stale, holds no memory,
and closes the head-read/scan TOCTOU; the per-read cost is documented on
the public API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): honor the exception contract for unsigned ID sequences

Redis stream ID components are unsigned 64-bit values; parsing the sequence
with long.Parse raised OverflowException for sequences beyond long range
instead of the documented NotSupportedException. Both the revision
conversion and the validation scan now parse as ulong and range-check, and
the millisecond part is range-checked before the signed conversion.

Also document the operational requirement that pre-fix writers are quiesced
before resumed reads are used: an old writer racing the gap between prefix
validation and the data read can append an unrepresentable entry below the
requested position, which only the next resumed read can reject.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(redis): reject positions overflowing at the encoding boundary

At milliseconds == long.MaxValue / 10 only sequences up to
long.MaxValue % 10 fit the signed position; sequence 8 and 9 wrapped to a
negative revision instead of throwing the documented NotSupportedException.
Also widen the documented quiescence requirement to every writer that
doesn't use this store version's explicit entry ID scheme, including
external XADD with auto-generated IDs, not only pre-0.16 store versions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(persistence): stop paged reads advancing past the maximum revision

An event at revision long.MaxValue that fills an exact page made
ReadStreamToEnd compute lastRevision + 1, wrapping negative and throwing
from the StreamReadPosition constructor after yielding the event. The
maximum revision is the end of the representable position space, so the
paged read now completes there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* fix(kurrentdb): bump KurrentDB.Client to 1.4.1 (#573)

A read that faults or is cancelled before the first response parks the
failure in two places: the message channel, which the enumerator
observes, and the public ReadState task, which nothing awaits. The
faulted ReadState task was then collected unobserved and surfaced on the
finalizer thread as a TaskScheduler.UnobservedTaskException.

Every read is affected, not just cancelled ones: the leak window is
"fault before the first response", so connection failures, auth
failures, deadline expiry and server unavailability all leak too.

Client 1.4.1 observes the fault at the source, and also fixes two
sibling sinks that no consumer can reach from outside: SharingProvider's
call-invoker boxes and the batch appender's fire-and-forget send loop.
Measured over 20 reads that fail before the first response, against a
closed port: 59 unobserved exceptions on 1.4.0 (18 from ReadState, 21
from SharingProvider retries, 20 from disposed boxes), 0 on 1.4.1.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test): stop Redis fixture aborting on first connection attempt (#572)

* fix(test): stop Redis fixture aborting on first connection attempt

The Redis test fixture connects right after the container reports ready, and
the first connection attempt occasionally races the server, failing the whole
fixture initialization with RedisConnectionException before any test runs.
abortConnect=false makes the multiplexer keep retrying instead of aborting.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Connect the Redis test fixture asynchronously with a shared multiplexer

Address the review note: replace the synchronous ConnectionMultiplexer.Connect
inside InitializeAsync with an awaited ConnectAsync. Connect once and share
the multiplexer across tests instead of opening a new connection per
GetDatabase call, and dispose it with the fixture; abortConnect=false is
preserved so the first attempt keeps retrying when it races the freshly
started container.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

* refactor(azure): review cleanup for the blob storage projection

- Remove leftovers from the extracted Aspire sample: orphan package
  versions and an unrelated ServiceBusSubscription signature change
- Consolidate JSON serialization config into BlobStorageProjectorOptions.JsonOptions,
  dropping the constructor serializerOptions parameter
- Warn when ByGlobalPosition idempotency receives events with global
  position 0, and document that the mode requires real global positions
- Add copyright headers, follow .editorconfig accessibility and naming
  conventions, inline the misnamed GetBlobContainerClient, drop the
  redundant On<TEvent> overload and the manual ValueTask fast-path
- Remove dead event store scaffolding and KurrentDB references from the
  test project, dedupe concurrent-modification test lambdas
- Fix README: stale IOptions claim, blob naming example, container
  existence requirement

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(azure): narrow blob projector exception handling and encode metadata

- Catch 404 only for the projection blob read (filtered to BlobNotFound)
  and 412/409 only for the conditional upload (filtered to
  ConditionNotMet/BlobAlreadyExists), so exceptions thrown by the
  user-supplied event handler are never misclassified as ETag races or
  missing-blob conditions and the handler is never re-invoked for them
- Percent-encode Stream and MessageId blob metadata values: Azure
  requires ASCII metadata, while stream names and message ids can be
  arbitrary strings (e.g. Booking-Ålesund previously failed uploads
  with InvalidMetadata)
- Add tests for both: handler-thrown RequestFailedException propagates
  without retries, and Unicode stream names project successfully with
  encoded metadata

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(azure): wrap only the Azure SDK awaits in the projector catch blocks

Deserialization and upload preparation (JSON serialization through the
user-configurable options, upload options and metadata construction) now
run outside the try blocks, so each catch classifies exclusively its own
SDK call: DownloadContentAsync for the missing-blob path and UploadAsync
for the concurrency-conflict path. Exceptions from user-supplied JSON
converters can no longer be misread as blob conditions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Mistral Vibe <vibe@mistral.ai>
Co-authored-by: Alexey Zimarev <alex@zimarev.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Pavel Borisov <Inok@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

KurrentDB ReadEvents buffers the entire requested range before yielding — IAsyncEnumerable is not actually streaming

1 participant